iT邦幫忙

2026 iThome 鐵人賽

DAY 21
1
AI Engineering

GPU很忙?他真的有在做事嗎?系列 第 21

Day 21|kernel 為什麼慢?nsys 點名之後,換 Nsight Compute 臨檢

  • 分享至 

  • xImage
  •  

簽幾天我們把主檔 A 的通訊問題拆完,nsys 的能力也差不多用到邊界了。它能告訴我們:flash_bwd 平均一顆 84 ms、nvjet_tst_192x192 那顆 GEMM 在大時差事件旁邊平均 58.8 ms、哪些 kernel 佔掉最多時間。但再往下問一層:這顆 kernel 的 174 µs 裡,是算力用滿、記憶體塞住,還是排程在空轉?timeline 上沒有答案。這一層的問題屬於 Nsight Compute(ncu)。

今天回答三件事:

  1. nsys 與 ncu 的分工是什麼,為什麼不能只用其中一個?
  2. ncu 怎麼運作,代價是什麼?
  3. 對一顆 memory-bound 與一顆 compute-bound 的 kernel,ncu 實際會給出什麼?

資料範圍提醒:ncu 需要在有 GPU 的機器上重跑目標程式,無法像 nsys-ai 那樣離線分析主檔 A 的 sqlite。本文的實測輸出來自 Modal 上的一張 H100 80GB(driver 580.95、ncu 2025.1.1),跑的是最小示範程式,不是主檔 A 的訓練。這張 H100 的 BF16 dense Tensor Core 峰值為 989 TFLOPS,HBM3 頻寬為 3.35 TB/s;A 檔的 H200 同樣是 989 TFLOPS,但 HBM3e 頻寬提高到 4.8 TB/s。A 檔那幾顆 kernel 的臨檢指令附在文末,仍要回原機器執行。

錄影與臨檢:兩層工具的分工

nsys 與 ncu 的差別可以用一句話記:nsys 是錄影,ncu 是臨檢

nsys 錄一次整段執行,事後可以反覆離線分析,這個系列前二十天都在做這件事,連 Mac 上都能查。它回答「誰慢、慢在哪、跟誰有關」。ncu 則是針對指定的 kernel 做深度健檢:同一個 pass 能同時收集的硬體 counter 有限,需要的 metrics 放不進一個 pass 時,它就會把 kernel 重放(replay)一次或多次,最後再把結果拼成一份報告。預設的 kernel replay 還會保存 kernel 可存取的 GPU 記憶體,並在後續 pass 前還原被寫過的區域,讓每次量測盡量從相同狀態開始。

收集的份量由 --set 決定:basic 只收核心 sections,full 收得更完整,也可能需要更多 pass 與記憶體保存成本。本文這次 --set full 的終端輸出顯示每個 kernel 跑了 39 passes;但實際數量會隨 GPU、metrics 與工具版本改變,39 不是固定規格。範圍縮得越準,代價才越可控。ncu 回答的是「這顆 kernel 為什麼是這個速度」。

replay 機制決定了 ncu 的代價與用法:

  • 不適合無差別檢查整段訓練。ncu 可能重放 kernel、保存與還原記憶體,也會為了 counter 歸屬而序列化 kernel launch;收得越多,執行狀態就離原本的 timeline 越遠。它適合檢查已經縮小範圍的目標,不適合把所有 kernel 都跑一遍 full
  • 要先點名再臨檢。用 -k 過濾 kernel 名稱、--launch-skip 跳過已確認不需採樣的前幾次、--launch-count 限制次數,把範圍縮到 nsys 已經點名的那幾顆。
  • 量測要求可重現。ncu 預設會鎖定 GPU 頻率讓多次 replay 可比較;在共享的雲端 GPU 上通常沒有鎖頻權限,要加 --clock-control none,代價是輸出多一行警告、數字也會受當下頻率影響。本文選用的兩筆結果出自同一份 full 報告,SM frequency 分別是 1.97 與 1.62 GHz,已經相差 0.35 GHz,因此適合判讀瓶頸方向,不適合拿來做百分點級的精密比較。

實際踩到的另一個坑值得記錄:第一次在 driver 580.95 的機器上用 CUDA 12.4 附的 ncu 2024.1,出現 Failed to prepare kernel for profiling;換成 ncu 2025.1 後就能執行。這次 A/B 只能證明「換版本後恢復」,不能只憑一個錯誤訊息認定根因一定是 driver 相容性;實務上應先查該版 ncu 的 support matrix、performance counter 權限與 known issues,再決定是否升級。

nsys 與 ncu 的分層:nsys 記錄整段 timeline,回答誰慢、慢在哪;ncu 對指定 kernel 收集 counter,需要時以一個或多個 pass replay,回答單顆 kernel 為什麼是這個速度

最小臨檢:兩顆 kernel,兩種瓶頸

示範程式只有兩個操作,正好對應 Day 2 的兩種瓶頸:4096×4096 的 BF16 矩陣乘(理論上 compute-bound),與 6,400 萬個元素的 BF16 加法(理論上 memory-bound)。實際執行的 mini.py 如下:

import torch

a = torch.randn(4096, 4096, device="cuda", dtype=torch.bfloat16)
b = torch.randn(4096, 4096, device="cuda", dtype=torch.bfloat16)
x = torch.randn(64_000_000, device="cuda", dtype=torch.bfloat16)
y = torch.randn(64_000_000, device="cuda", dtype=torch.bfloat16)

for _ in range(3):
    c = a @ b
    z = x + y

torch.cuda.synchronize()

ncu 指令:

ncu --set full --clock-control none \
    --target-processes all \
    -k "regex:(nvjet|sm90|cutlass|gemm|vectorized_elementwise)" \
    --launch-count 6 python mini.py

有個小插曲:第一版過濾條件寫的是 gemm|cutlass|sm90,結果一顆 GEMM 都沒抓到:這台 H100 上 torch 2.7 的 BF16 矩陣乘,kernel 名稱是 nvjet_tst_256x128_64x4_1x2_h_bz_coopA_NNT,跟主檔 A 裡的 GEMM 同一個 cuBLAS 命名家族。Day 18 說過用名稱認 kernel 的清單會過期,這次連示範都當場示範了一遍。

先看 elementwise 加法的 Speed Of Light(SOL)節,這是 ncu 報告的第一節,也是判讀的起點:

void vectorized_elementwise_kernel<8, CUDAFunctor_add<BFloat16>, ...>
  Section: GPU Speed Of Light Throughput
  Memory Throughput            %    90.16
  DRAM Throughput              %    90.16
  Compute (SM) Throughput      %    11.73
  Duration                    us   121.73
  Memory Throughput      Tbyte/s     3.02

再看 nvjet GEMM 的同一節:

nvjet_tst_256x128_64x4_1x2_h_bz_coopA_NNT
  Section: GPU Speed Of Light Throughput
  Compute (SM) Throughput      %    89.82
  Memory Throughput            %    69.27
  DRAM Throughput              %    30.58
  Duration                    us   174.75

兩顆 kernel 呈現近乎相反的型態。加法的 DRAM throughput 到 90%,實測 3.02 TB/s,已經接近這張卡 3.35 TB/s 的頻寬上限;Compute (SM) throughput 則只有 11.73%。GEMM 的 Compute (SM) throughput 到 89.82%,DRAM throughput 只有 30.58%。這裡的百分比是各硬體單元相對自身 peak rate 的 throughput,不是「有多少比例的 SM 正在工作」。

因此,SOL 適合拿來做第一輪分流:DRAM 接近峰值、Compute 明顯較低,通常先往 memory-bound 查;Compute 接近峰值,則先往 compute-bound 查。兩邊都低時,可能是工作量太小、平行度不足、指令相依、同步或其他 latency 問題,還要繼續看 Scheduler Statistics 與 Warp State Statistics。CPU launch 是否跟不上則屬於 nsys 的時間軸問題,不能只靠 ncu 的兩條 SOL 百分比定案。Day 20 真正排除的,也只是八次大時差中的「CPU 太晚送出 NCCL launch」,不是整份 A 檔完全沒有 launch overhead。

往下翻幾節,可以看到更多線索。加法的 Scheduler Statistics 顯示:每個 scheduler 平均有 14 個 active warps,但每個 cycle 平均只有 0.14 個 eligible warp,87.9% 的 cycles 沒有 warp 可以發射(No Eligible)。這代表「warp 在場,卻暫時不能發下一條指令」,單看這裡還不知道在等什麼。

接著看 Warp State Statistics,答案才補齊:每發出一條指令,warp 平均有 107.5 cycles 卡在 L1TEX 操作的 scoreboard dependency,約占兩次發射間隔的 92.5%。把這項 stall、DRAM throughput 90% 與低 eligible warps 放在一起,才能說主要瓶頸確實來自記憶體資料尚未就緒;不是看到 No Eligible 就自動等同 DRAM-bound。

Memory Workload 節則顯示 L1 hit rate 為 0%、L2 hit rate 為 34.5%。對一次性串流讀寫的 elementwise kernel 而言,L1 幾乎沒有重用並不意外;但 L2 仍接住一部分請求,所以也不能說所有流量都直接打到 DRAM。這一段真正可靠的結論,是它已經把 DRAM throughput 推到九成,而不是單靠 cache hit rate 猜每個 byte 的路徑。

報告裡的 OPT 建議行也要小心讀。ncu 會在各節附上規則引擎產生的優化提示與預估值:這次它對加法列出「Est. Local Speedup: 92.27%,所有 compute pipeline 都未充分利用」。Local Speedup 估的是該硬體資源的局部使用效率,不等於整顆 kernel 能縮短 92%。如果忽略 DRAM 已到九成,只看這一行就去追 compute pipeline,方向反而會錯。OPT 提示適合拿來產生下一個問題,不是單獨下結論的權威。

GEMM 的 Memory Workload 數字則是 L1/TEX throughput 73.2%、L2 throughput 59.2%、DRAM throughput 30.6%。這三個百分比各自除以不同單元的 peak rate,不能排成 73 → 59 → 31,再解讀成同一批流量逐層被快取吃掉。它們能告訴我們哪些單元相對忙,若要證明資料重用與實際 HBM 流量,還得看 dram__bytes.sum、cache hit rate、sector count,以及 shared-memory transactions。

順帶一提,SOL 的 Memory Throughput(69.27%)是記憶體子系統各組成指標的高階彙整值,跟 DRAM Throughput(30.58%)不是同一個分母。這顆 GEMM 可以安全下的結論是:SM throughput 已接近自身峰值,而 DRAM 尚未接近頻寬峰值,型態與 compute-bound 相容;不能只靠三個百分比還原完整資料流。

換算成絕對值更有感。GEMM:2 × 4096³ = 137.4 GFLOP,除以 174.75 µs 得 786 TFLOPS,是 BF16 dense Tensor Core 峰值 989 的 79.5%。Day 17 說純矩陣乘的單元測試天花板約 70–80%,這顆 kernel 正好落在錨點帶的上緣。

加法:6,400 萬次加法除以 121.73 µs 得 0.526 TFLOPS。它每個元素做一次加法,同時至少讀兩個 BF16 輸入、寫一個 BF16 輸出,理論運算密度為 1 FLOP ÷ 6 bytes ≈ 0.167 FLOP/byte;放到 3.35 TB/s 的頻寬屋頂上,最高約為 0.558 TFLOPS。因此實測已達自己的理論 DRAM roofline 約 94.2%。這和 SOL 報告的 90.16% 同樣指向接近 DRAM 上限,但不必期待兩者完全相等:前者用規格頻寬與最低必要 bytes 手算,後者來自硬體 counter,而且這次關閉了 clock control,不同 replay pass 的頻率也可能不同。這裡同樣不拿 0.526 除以 989:elementwise add 沒走 GEMM 的 Tensor Core 路徑,拿 Tensor Core 峰值當它的直接比較對象沒有意義。

兩顆 kernel 的 SOL 對比與 occupancy 反差:elementwise 的 DRAM throughput 90.16%、理論頻寬屋頂達成率約 94.2%,occupancy 86.79%;nvjet GEMM 的 SM throughput 89.82%、BF16 Tensor Core 峰值達成率 79.5%,occupancy 只有 14.64%。SOL 是分流線索,仍需用 stall 與 memory metrics 確認原因

把實測吞吐量放上理論 roofline

Day 17 的 roofline 用的是紙上估算,並且說「實際吞吐量要等 Day 21」。現在可以補上,但先把口徑標清楚:下圖的 y 軸以演算法 FLOPs 除以 ncu 實測時間;x 軸仍以演算法的最低必要流量估算,不是 ncu 的 dram__bytes.sum。H100 的理論 ridge point 是 989 ÷ 3.35 ≈ 295 FLOP/byte,和 H200 的 206 不同。

  • elementwise add:理論密度 0.167 FLOP/byte,實測 0.526 TFLOPS,約達頻寬屋頂的 94.2%;
  • nvjet GEMM(N=4096):若 A、B 各讀一次、C 寫一次,理論密度約為 N ÷ 3 ≈ 1365 FLOP/byte;實測 786 TFLOPS,約達 Tensor Core 峰值的 79.5%。

GEMM 的 x 座標是樂觀上限:真實 kernel 可能有額外讀寫、epilogue 或重複載入,實際運算密度會往左移。若要把整張圖升級成完整實測版,下一次應直接收 dram__bytes.sum,用 FLOPs ÷ 實際 DRAM bytes 重算兩個 x 座標。

一個工具細節要註明:標準 overview roofline 裡的 FP32/FP64 指標,對 BF16 Tensor Core kernel 可能顯示 0;這不等於 ncu 不支援 tensor roofline。ncu 另有 SpeedOfLight_HierarchicalTensorRooflineChart,而且從 2025.1 起,full set 已納入所有 roofline sections。判讀時要找到 tensor roofline 對應的 section,不能把 FP32/FP64 的 0% 當成 GEMM 沒有做運算。本文仍手算 FLOPs,是為了把公式與口徑攤開,而不是因為工具只能報 FP32/FP64。

H100 dense BF16 roofline:y 軸是 ncu 實測時間換算的吞吐量,x 軸是演算法最低流量估出的理論運算密度。elementwise add 為 0.167 FLOP/byte、0.526 TFLOPS;nvjet GEMM 為理論 1365 FLOP/byte、786 TFLOPS。若要得到完整實測座標,還需補收 dram__bytes.sum

occupancy 的反差教材

ncu 的 Occupancy 節在這兩顆 kernel 上給出一組漂亮的反例:

elementwise add nvjet GEMM
Theoretical Occupancy 100% 18.75%
Achieved Occupancy 86.79% 14.64%
Registers Per Thread 32 168
相對主要屋頂 DRAM roofline 94.2% Tensor Core peak 79.5%

occupancy 高的 elementwise add 已貼近自己的 DRAM 屋頂;occupancy 只有 15% 的 GEMM,也能達到 BF16 Tensor Core 峰值的近八成。原因不神祕:occupancy 衡量的是「SM 上駐留多少 warp 可供切換」,它是隱藏延遲的手段,不是效能目標。這顆 GEMM 每個 thread 使用 168 個 registers,理論上只允許 18.75% occupancy;grid 正好有 132 個 blocks,等於這張 H100 的 SM 數。即使駐留 warp 不多,它仍能讓主要計算管線維持高吞吐。加法則有很多 warp 可切換,但 DRAM 已接近上限,再增加 occupancy 也不會憑空增加 HBM 頻寬。看到低 occupancy 就調高、看到高 occupancy 就安心,兩個方向都可能誤診。

回到主檔 A:臨檢清單

nsys 這二十天在 A 檔點的名,整理成 ncu 的待檢清單(device 4、五步窗的實測):

目標 kernel 次數 平均 想問 ncu 的問題
flash_bwd_dq_dk_dv_loop_seqk_parallel 20 84.05 ms SOL 落在哪、tensor core 利用率多少
nvjet_tst_128x320_..._NNN 85 8.04 ms 與示範 GEMM 的 79.5% 相比差多少
nvjet_tst_192x192_..._NTN 5 58.78 ms Day 20 大時差旁的那顆,SOL 是否正常

對應的指令如下。ncu 2025.1 預設會追蹤 child processes,不過在 torchrun 前仍明寫 --target-processes all,也用 --devices 4 把量測範圍對齊上表的 device 4:

ncu --target-processes all --devices 4 \
    --set full -o flash_bwd_report \
    -k "regex:flash_bwd_dq_dk_dv" \
    --launch-skip 20 --launch-count 3 \
    torchrun <原訓練指令>

這裡的 --launch-skip 20 只有在「前 20 次命中的同名 kernel 確實屬於 warmup」時才成立,不能把 20 當成通用值。更穩的方式是先用 nsys 數清 invocation,或加上 NVTX range,再用 --kernel-id/NVTX filter 指定目標。

ncu 與 nsys 建議分開錄,但原因不是兩者都「接管 CUDA 事件」。ncu 會序列化 kernel launch,預設也可能清 cache、控制 clock;若外層再包 nsys,錄到的是臨檢狀態,不是原本訓練的 timeline。兩份報告的 kernel duration 也不一定相同,應分開採集並對齊 clock、cache 與輸入條件。若程式中的 NCCL 等通訊 kernel 必須由多個 processes 同時前進,replay 與序列化還可能讓作業卡住;此時要參考 ncu 的 multi-process communicator/lockstep 模式,或先做能重現目標 compute kernel 的最小程式。產出的 .ncu-rep 則可以帶回沒有 GPU 的機器,用 Nsight Compute GUI 開啟;目前 macOS 也支援作為分析端。

三種常見的誤判

第一,把 occupancy 當 KPI。上面那張表就是反例:它是遮延遲的手段,該高不高才是問題,跟「越高越好」是兩回事。

第二,看到 Compute 低就直接調計算管線。這顆加法的 DRAM throughput 已到 90%,低 Compute 並不是主要警訊。若仍要改善,方向通常是融合前後操作、減少 HBM 往返,或乾脆減少這類 kernel 的呼叫;先確認它離自己的屋頂多遠,再決定值不值得動。

第三,沒點名就臨檢。ncu 一顆一顆慢慢查,對沒進 top-N 的 kernel 做全套健檢是純浪費;nsys 先把時間帳排出來,ncu 只碰值得碰的。反過來,在 ncu 的 replay 模式下量到的整體時間也不能當效能數字用。

小結與明天

今天把工具層級往下推了一層。分工:nsys 錄影,回答誰慢、慢在哪;ncu 臨檢,回到有 GPU 的環境收 counter,回答單顆 kernel 為什麼是這個速度。實測裡,elementwise 的 DRAM throughput 90% 對 SM throughput 12%,GEMM 則是 SM 90% 對 DRAM 31%,兩種瓶頸第一次以 counter 的形式並排出現。GEMM 實測 786 TFLOPS,約達 BF16 Tensor Core 峰值的 79.5%;加法實測 0.526 TFLOPS,約達自己的理論 DRAM roofline 94.2%。兩者的 occupancy 一高一低,正好提醒我們:occupancy 是隱藏延遲的條件之一,不是越高越好的 KPI。A 檔的三顆嫌疑 kernel 已列好臨檢單,等回到原機器執行。

明天再往下一層:kernel 內部的指令級行為,nsys-ai 整合的 CUTracer 登場,以及為什麼它跟 nsys 不能同時開。

參考資料


上一篇
Day 20|誰在拖慢 LLM 多卡訓練?straggler 的視覺特徵
下一篇
Day 22|鵝鵝鵝!kernel 裡面在跑什麼指令?CUTracer 的三步流程
系列文
GPU很忙?他真的有在做事嗎?22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言